Skip to main content

Functions, Objects and Modules

Functions

Traditionally, functions, a mathematical term, were described in computer terms as a code block that did work, performed calculations, on some data inputs and returned the results of those calculations as outputs.

Procedures on the other hand are described as blocks of code that would control a flow of events, i.e. a series of functions or input output tasks in a set order, like a recipe, calling functions for any required calculations.

In Python, we use the term function for any code block that does either of the above. In Python, they are syntactically equivalent.

Functions are defined with the def keyword, short for 'define or definition' take your pick, followed by the name of the function and any parameters it may have. This is called the function head. The function head is what identifies the function to the rest of the world, well our codebase at least. The head is followed by the function body which contains whatever code we associate with the function.

The following function will convert from Celsius to Fahrenheit

def convert_to_fahrenheit(celsius):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
fahrenheit = (celsius * 1.8000) + 32
return fahrenheit

Clearly, function name convert_to_fahrenheit is in lowercase and words are separated by _ underscores. It has one parameter celsius and the head is terminated by a : colon. Before the start of the code in the body of the function we have a 'Doc String' describing what the function does, the parameters and what it returns. Although a 'Doc String' is optional, it is recommended and can be used to auto generate documentation for your code at a later date.

The code itself is only two lines long but does the job. The result is returned using the return statement.

The return statement is what it says on the tin. It returns values to the caller. The caller is responsible for handling those returned values.

Not all functions have to return a value. You may perform some tasks such as input and output without the necessity to return anything. Sometimes a return will only be used when a function fails to perform what is asked without creating an error. Using return False is used in these circumstances.

import random

def weather_windy(weather):
"""
Function needs to ensure x is less than 10
"""
if weather != "yes":
return False

result = weather_windy(random.choice(["yes", "no"]))

if not result:
print("Weather is not windy")
else:
print("weather is windy")


Calling the above will return a None if the weather is windy, or a False if it is not. To guarantee a False or a True return we could use the following

import random

def weather_windy(weather):
"""
Function needs to ensure x is less than 10
"""
if weather == "yes":
return True

return False

result = weather_windy(random.choice(["yes", "no"]))
if not result:
print("Weather is not windy")
else:
print("weather is windy")

We can call functions such as weather_windy above and our previous convert_to_fahrenheit function from anywhere in our code as long as it is in the same file, or we import it using an import statement (see the section on Modules)

To call a function, in this case convert_to_fahrenheit we use the following statement, passing in any required parameters:

convert_to_fahrenheit(25)

If we create another function for the user input of a Celsius temperature, we can pass it to our conversion function. Using functions, provides a structured shape to our code, and makes it easier to read and follow.

def convert_to_fahrenheit(celsius):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
fahrenheit = (celsius * 1.8000) + 32
return fahrenheit

def get_celsius_temp():
"""
Asks for some input for a celsius temperature and passes that input as a parameter,
converting from string to integer, when calling the convert_to_fahrenheit function.
"""
celsius = input("Provide a temperature in Celsius")
fahrenheit = convert_to_fahrenheit(int(celsius))
print(f"Celsius: {celsius} = {fahrenheit} Fahrenheit")

get_celsius_temp()

Function parameters

The convert_to_fahrenheit function takes a single parameter, celsius, but what happens if that parameter, a variable, does not have a value because the call to it, forgot the value or the value was of the wrong type?

Depending on which, i.e. no value or wrong type, we'll get one of two types of error. In this case, a TypeError if the value is missing or a ValueError if the value is the wrong type. Python will not run past the point of an error, the interpreter breaks out of its run process and provides details of the error in what is called a 'Traceback', which is basically a list of the most recent calls that were made upto and including the call where the error occurred.

Try it out yourself, run the code above again, but this time don't input an integer, input a string of a few characters and see what happens. Later on we'll look at how to make sure that we handle these errors appropriately.

Parameter defaults

Sometimes it is useful to provide default values for parameters in functions. These are used when no parameters are passed, i.e. when parameters are optional or default to a value.

def convert_to_fahrenheit(celsius=25):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
fahrenheit = (celsius * 1.8000) + 32
return fahrenheit

fahrenheit = convert_to_fahrenheit()
print(f"{fahrenheit} Fahrenheit")
fahrenheit = convert_to_fahrenheit(30)
print(f"{fahrenheit} Fahrenheit")

In the first call above, the convert_to_fahrenheit function uses the default value of celsius=25 if no other value is assigned to `celsius'.

Sometimes a parameter may have a default value of None i.e. no value. Let's change our function above to include an optional wind speed conversion

def weather_conversions(celsius=25, wind_kmh=None):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree
Optionally convert wind speed from kilometers per hour to Miles per hour

celsius: Celsius degree input - default = 25
wind_kmh: wind speed in kilometers per hour
Returns: Equivalent fahrenheit degree and optionally wind speed in miles per hour
"""
fahrenheit = (celsius * 1.8000) + 32

if wind_kmh:
wind_mph = wind_kmh / 1.61
return fahrenheit, wind_mph

return fahrenheit


# Call without any parameters
fahrenheit = weather_conversions()
print(f"{fahrenheit} Fahrenheit")

# Call with just celsius
fahrenheit = weather_conversions(20)
print(f"{fahrenheit} Fahrenheit")

# Call with both celsius and wind speed in kmh
fahrenheit, wind_mph = weather_conversions(30, 20)

print(f"{fahrenheit} Fahrenheit")
print(f"{wind_mph} Wind Speed - mph")

In the function weather_conversions we are using an if condition to test the variable wind_kmh. If it has a value we are returning both the fahrenheit conversion and the wind speed conversion. If not just the fahrenheit.

We could provide the wind_mph a default value of None and return both it and the fahrenheit regardless of whether we perform the wind speed conversion

def weather_conversions(celsius=25, wind_kmh=None):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree
Optionally convert wind speed from kilometers per hour to Miles per hour

celsius: Celsius degree input - default = 25
wind_kmh: wind speed in kilometers per hour
Returns: Equivalent fahrenheit degree and optionally wind speed in miles per hour
"""
fahrenheit = (celsius * 1.8000) + 32
wind_mph = None

if wind_kmh:
wind_mph = wind_kmh / 1.61

return fahrenheit, wind_mph


# Call without any parameters
fahrenheit, _ = weather_conversions()
print(f"{fahrenheit} Fahrenheit")

# Call with just celsius
fahrenheit, _ = weather_conversions(20)
print(f"{fahrenheit} Fahrenheit")

# Call with both celsius and wind speed in kmh
results = weather_conversions(30, 20)

print(f"{results[0]} Fahrenheit")
print(f"{results[1]} Wind Speed - mph")

The effect of returning the wind_mph needs to be accounted for in the calling code. When a function returns more than one value, it returns a tuple. The calling code can either accept one returned parameter as a tuple or several parameters separated by commas, , depending on the expected number of values in the tuple.

Above, the results are separated by a comma. The second parameter is an _ this is because we did not provide a wind_kmh value as a parameter to the function. We clearly don't require the result of that particular conversion, so we use the _ to indicate that we are ignoring that returned value. Even if we did provide a wind_kmh value, we could still ignore it this way.

fahrenheit, _ = weather_conversions()
print(f"{fahrenheit} Fahrenheit")

In the following we're accepting the results as a tuple and using an index to access them.

# Call with both celsius and wind speed in kmh
results = weather_conversions(30, 20)

print(f"{results[0]} Fahrenheit")
print(f"{results[1]} Wind Speed - mph")

Functions as parameters

Functions themselves can be passed as a parameter and run in another function. Going back to our convert_to_fahrenheit function...

def convert_to_fahrenheit(get_input):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
print(get_input)
celsius = get_input()
fahrenheit = (int(celsius) * 1.8000) + 32
return fahrenheit

def get_celsius_temp():
"""
Asks for some input for a celsius temperature and passes that input as a parameter,
converting from string to integer, when calling the convert_to_fahrenheit function.
"""
return input("Provide a temperature in Celsius")

fahrenheit = convert_to_fahrenheit(get_celsius_temp)
print(fahrenheit)

We are actually sending a pointer to the function get_celsius_temp as a parameter to our convert_to_fahrenheit function. get_celsius_temp is then called from the convert_to_fahrenheit function.

Basically, we are assigning the function to a variable and running it later. The following will do the same

def convert_to_fahrenheit(celsius):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
fahrenheit = (celsius * 1.8000) + 32
return fahrenheit

def get_celsius_temp():
"""
Asks for some input for a celsius temperature and passes that input as a parameter,
converting from string to integer, when calling the convert_to_fahrenheit function.
"""
celsius = input("Provide a temperature in Celsius")
fahrenheit = convert_to_fahrenheit(int(celsius))
print(f"Celsius: {celsius} = {fahrenheit} Fahrenheit")

a_var = get_celsius_temp
a_var()

The variable a_var contains the function get_celsius_temp and when we want to call it we just add () to the end of a_var. Of course, if there were parameters required for the function we would place those inside the () brackets.

The following code passes a set of functions in two lists to the function weather_conversions. From this function the other functions are called and results saved and returned.

def celsius_to_fahrenheit(celsius):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
fahrenheit = (celsius * 1.8000) + 32
return fahrenheit


def get_celsius_temp():
"""
Asks for some input for a celsius temperature and passes that input as a parameter,
converting from string to integer
"""
return int(input("Provide a temperature in Celsius"))


def wind_speed_conversion(wind_kmh=10):
"""
Convert wind speed from kilometers per hour to Miles per hour

wind_kmh: wind speed in kilometers per hour
Returns: Equivalent wind speed in miles per hour
"""

wind_mph = wind_kmh / 1.61
return wind_mph


def get_wind_speed_kmh():
"""
Asks for some input for wind speed in kilometers per hour,
converting from string to integer
"""
return int(input("Provide a kilometer per hour wind speed"))


def weather_conversions(input_functions, conversion_functions):

results = []

for i in range(0, len(input_functions)):
result = input_functions[i]()
conversion = conversion_functions[i](result)
results.append(conversion)

return results


inputs = [get_celsius_temp, get_wind_speed_kmh]
conversions = [celsius_to_fahrenheit, wind_speed_conversion]
results = weather_conversions(inputs, conversions)

print(f"{results[0]} Fahrenheit")
print(f"{results[1]} Wind Speed - mph")

Functions can call themselves too. This is known as recursion.

def add_10(x, n):
if x > 100:
return x
x += 10
print(x)
add_10(x, n)

add_10(10, 10)

When using recursion it is important that there is some condition that triggers a return of the function to stop the recursion or the function will continually call itself, slowing down the system and eventually running out of memory. We'll discuss Python memory management later on.

More on Function Parameters

It might seem odd to discuss more details on parameters after functions, but at least now we have a good idea of how we structure and use functions and a basic idea of function parameters.

In our function examples above we have used what are called 'positional arguments', i.e. values that are passed, as parameters to the function that appear in the function head in the order they were sent. Functions can however take any number of values without declaring separate parameters for each argument in the head. We use the terms arguments and keyword arguments for assigning a number of parameters to keywords denoted by using, * and ** as a prefix. Let's see this in practice.

A simple set of number parameters sent to a function which also receives them in the order they were sent

def num_func(num1, num2, num3, num4):

print(num1, num2, num3, num4, sep="-", end=" ")

num_func(1, 2, 3, 4)

Let's spin this a bit and do the following

def num_func(num1, *args):

print(num1, args, sep="-", end=" ")

num_func(1, 2, 3, 4)

Run the code in and see what it prints.

Number '1' is as it should be in parameter num1, but look at where 2, 3 and 4 are. They're in a tuple in args, (1,2,3). Even though we sent them in sequence in the function call, what we are doing in the function head is telling Python that those values are to be contained in the arguments variable.

So what is the difference between parameters and arguments?

Parameters are variables assigned a value, i.e. an argument. Python allows us to declare another parameter *args or *anyname to indicate a set of arguments after the last positional argument without assigning the arguments to a specific positional named parameter. To make clear what we mean by 'positional' check the code below:

def num_func(num1, num2, *args):

print(num1, num2, args, sep="-", end=" ")

num_func(1, 2, 3, 4)

This time the tuple contains only (3,4). This is because 1 and 2 are contained in the positional parameters num1 and num2. Python knows how to split the values passed into positional parameters and an arguments' parameter because we use the * prefix on the arguments' parameter.

The advantage of this is that we can group related values/arguments, and we can also avoid an unseemly number of positional parameters in the function head.

Indeed, we can actually just use an *args parameter and no positional parameters

def num_func(*args):

print(args, sep="-", end=" ")

num_func(1, 2, 3, 4)

The args tuple now contains all the numbers we sent as parameters, (1, 2, 3, 4)

To act on those parameters individually we could either iterate over the args parameter in a loop or use positional indexes if the function knows what each parameter represents. We can even do a batch assign. Look at the following code to see the possibilities.

def num_func(*args):

for i in args:
print(i, end=" ")

print("\n")
print(args[0])
print(args[1])
print(args[2])
print(args[3])
print("\n")

n1, n2, n3, n4 = args
print(n1, n2, n3, n4, end=" ")

num_func(1, 2, 3, 4)

Let's look at another argument type, 'keyword' arguments called kwargs. The term 'keyword' arguments provides the clue to what they are. That's right, a dictionary of key value pairs. The arguments are passed in using assignments and received as a dictionary of key-value pairs. The kwargs argument is preceded with two asterix **kwargs and like args can be called anything as long as there is consistency in its reference.

def about_user(name, *args, **kwargs):

print(kwargs)
print(f"The user, {name}, is {args[0]} and {kwargs['height']} metres tall, and weighs {kwargs['weight']} kilos")

about_user("Richard", "male", height="1.82", weight="82.0")

The function head arguments:

name = "Richard" *args = ("male",) **kwargs = {'height': '1.82', 'weight': '82.0'}

Below we have changed the default names of args and kwargs but it makes no difference the code still works.

def about_user(name, *foos, **bars):

print(bars)
print(f"The user, {name}, is {foos[0]} and {bars['height']} metres tall, and weighs {bars['weight']} kilos")

about_user("Richard", "male", height="1.82", weight="82.0")

Notice the ordering of the arguments, this is important! Positional arguments always come before args and kwargs, and args before kwargs.

Try it the wrong way

def about_user(*args, name, **kwargs):

print(kwargs)
print(f"The user, {name}, is {args[0]} and {kwargs['height']} metres tall, and weighs {kwargs['weight']} kilos")

about_user("Richard", "male", height="1.82", weight="82.0")

The above will generate TypeError: about_user() missing 1 required keyword-only argument: 'name'

Objects

An object is a representation of some entity. It could be anything we know, a car, an apple a house, a computer, a tree, literally anything. All of these objects have certain attributes that make them what they are. For example, a car has wheels, it requires fuel, it moves, it can be driven, it has a color, a brand, and a top speed plus many other attributes not least an engine. We could of course have a car without an engine, which would mean the car is not functional, but it would still be a car, just without an engine.

In object-oriented programming paradigms we use objects to represent major components of our application. Each object is individual but belongs to a class of objects.

Classes

The class defines the attributes of an object that are indisputable, in other words the basic attributes that make the object what it is. A class can also inherit from another class and place the object in some form of taxonomy, for example, both cars and motorbikes are vehicles because they inherit from Vehicles.

A class definition constructs an object and defines characteristics of that object. Every Python class has several built-in functions, the most important is perhaps the __init__() function or method as class functions are often called.

The __init__() function is where we create our default attributes and values for a class.

There are three main types of methods in a Python class:

Instance methods

Instance methods act on an instance of a class. When you create a car object from a class called Car. The car object is an instance of the class Car. And will inherit all the instance values set within the __init__() method of the class. Thus, apart from any instance values that are set via parameters when calling the class constructor, all new instances of a class will have the same values if those values are defined by default in the __init__() method of the class.

Of course, each child object will also inherit the parent class attributes, but these are generic attributes that apply to all child objects and not just a single child.

These methods use the self keyword to define that they belong to the instance itself. It is possible to use any other term, for example, obj in place of self or something more descriptive...

Class methods

Class methods act on the attributes belonging to the class itself and not specifically to an Instance of the class. In the example below we have a class attribute called num_cars, which sets a class wide attribute for the number of cars that have been created. You cannot change this from an instance, but you can still access it. This is because the class method called from the instance gets the attribute and returns it.

Class methods and data are referenced by the argument cls

Static methods

Static methods are helper methods. Useful for performing calculations relevant to a class. We don't normally use any instance or class variables in static methods. These methods require no arguments such as cls or self.

Python has decorators that we can use when declaring static or class methods in a class. These are:

1. @staticmethod
2. @classmethod

We'll learn more about decorators later, but for now suffice to say that they are placed just above the function head.

Defining Classes

Python classes are defined and initiated as follows:

Each class definition consists of attributes and methods (functions). Each attribute and method may belong to the class or an object (instance) of the class. If the former, the attribute is shared among all the instances of the class.

Inheritance

Inheritance is a way of defining different types of classes that inherit certain features from another class. In the example that follows, we have a class called Vehicles. This class will serve as the parent class to different types of vehicles such as cars, motorbikes, trucks etc. etc. Each of the different types of vehicles will inherit a number of attributes from the parent class Vehicles as well as having their own unique set of attributes.

It is possible for a class to inherit from more than one class (multiple inheritance), thus, inheritance is a powerful way of defining a taxonomy of related but independent objects. Inheritance is not a requirement to define classes or their subsequent instances, it all depends on the scope and level of detail that is required in defining objects and their relationships.

Encapsulation

Encapsulation means to encapsulate the usage of class methods and certain data, making them callable only from within the class itself and not as a method of an instance. Encapsulation exists as a way of protecting certain functions and data from interference from outside the class itself, normally by accident.

Our first example

class Vehicles:

# Class Attributes - Apply to all Vehicles
moves = True
driven = True
engine = True
requires_fuel = True
has_drive_shaft = True
has_wheels = True
carries_passengers = True
__num_cars = 0
__num_bikes = 0
__num_trucks = 0
__vehicle_count = 0

@classmethod
def __delete_car(cls):
cls.__num_cars -= 1

@classmethod
def __delete_bike(cls):
cls.__num_bikes -= 1

@classmethod
def __delete_truck(cls):
cls.__num_trucks -= 1

@classmethod
def delete_vehicle(cls, vehicle_type):

match vehicle_type:
case "car":
cls.__delete_car()
case "motortbike":
cls.__delete_motortbike()
case "truck":
cls.__truck()

@classmethod
def add_vehicle(cls, vehicle_type):

if vehicle_type == 'car':
cls.__num_cars += 1
elif vehicle_type == 'bike':
cls.__num_bikes += 1

cls.__vehicle_count += 1

@classmethod
def get_vehicles_by_type(cls, vehicle_type):

match vehicle_type:
case "car":
return cls.__num_cars
case "motortbike":
return cls.__num_bikes
case "truck":
return cls.__num_bikes

@classmethod
def get_vehicle_count(cls):
return cls.__vehicle_count

@staticmethod
def convert_km_to_mile(km):
return km / 1.6

# Instance method - relates to the instance object only.
def description(self):
desc_str = f"This vehicle is a {self.brand} {self.model} {self.category} - color {self.color} with a top speed of {self.max_speed} km an hour."
return desc_str

Our Vehicles class has eight methods, amongst which are a single instance method description that can be shared by all object instances and seven class methods that can be called by each object instance or indeed any other code regardless of whether it is part of an object or not.

We have our parent class, now let's create a child class.

class Car(Vehicles):

def __init__(self, brand, model, category, fuel_type="petrol", car_color="white"):
try:
self.__brand = brand
self.__model = model
self.__category = category
self.color = car_color
self.number_of_wheels = 4
self.working = True
self.fuel_type = fuel_type
self.kilometers = 0
Vehicles.add_vehicle('car')
except AttributeError as e:
print("Cannot create car - brand, model and category are all required")

def __del__(self):
Vehicles.delete_vehicle("car")

@property
def brand(self):
return self.__brand

@property
def model(self):
return self.__model

@property
def category(self):
return self.__category

Our Car class inherits from the class Vehicles. It can access all the non-private attributes that are defined in Vehicles, but it has some independent attributes, some of which are encapsulated, (we'll explain encapsulation a little later), namely __brand, __model, and __category. These can only be set when creating the instance of the object. They are attributes that should not change and are therefore not settable once assigned in the object instance constructor __init__, although we have added some class instance methods to access these attributes, using some decorators to define these as properties (we'll also explain properties a little later). We can access our encapuslated attributes indirectly, but as previously stated we cannot assign new values to them.

The only attributes we can reassign after creating the instance are number_of_wheels, color, fuel_type in case we convert the engine, and working. We could introduce methods to get and set these attributes, but we don't really need to, we can get and set these by just setting them on the objects instance variable, i.e. car.number_of_wheels_wheels and car.color etc. It also has some other attributes that can indeed be changed, number_of_wheels, we might modify the car to a three wheel model, color, and working.

Notice the last line of the __init__ function. It calls Vehicles.add_vehicle. This fires up the class method add_vehicle in the Vehicles object. This will increment by 1 the class variables __num_cars and __vehicle_count, which are both encapsulated - in this case, accessible only within certain class methods which are also encapsulated.

Up until now, we don't have an actual object instance, i.e. an instance of a car, only a class definition for a car. We'll use a function to create our car objects.

def create_car(brand, model, max_speed, category, fuel_type, car_color):
car = Car(brand, model, category, fuel_type)
car.max_speed = max_speed
car.color = car_color
return car

car = create_car("Toyota", "Corrola", "160", "saloon", fuel_type="diesel", car_color="red")
print(car.description())
print("number of cars = ", Vehicles.get_vehicles_by_type("car"))
print(car.color)

Now copy all the above code to a file - call it vehicles.py and run it.

The second to last print statement returns the number of cars and the last print statements returns red as we set it when creating the car object instance.

Below we are adding several a couple of other methods, not that we needed to but in the set_working_status method we can control the value of the attribute. Leaving it as it was, we could have set it to anything, e.g. car.working="anyoldvalue, which wouldn't be suitable as we only want a boolean value here, either True or False.

class Car(Vehicles):

def __init__(self, brand, model, category, fuel_type="petrol", car_color="white"):
try:
self.__brand = brand
self.__model = model
self.__category = category
self.color = car_color
self.number_of_wheels = 4
self.working = True
self.fuel_type = fuel_type
self.kilometers = 0
Vehicles.add_vehicle('car')
except AttributeError as e:
print("Cannot create car - brand, model and category are all required")

def __del__(self):
Vehicles.delete_vehicle("car")

@property
def brand(self):
return self.__brand

@property
def model(self):
return self.__model

@property
def category(self):
return self.__category

# Although the following is an instance method and you would expect to see `self`, you can use any other term as long as it
# is used consistently throughout the method, you cannot use `obj` in the head and then `self` in the body to reference the instance attributes.
# It could be called `foo` if that takes your fancy, but bear in mind consistency is key, don't mix and match.
# This is purely an example to show you the possibilities.
def set_number_of_wheels(obj, wheel_number):
obj.number_of_wheels = wheel_number

def set_working_status(self, working):
if working:
self.working = True
else:
self.working = False

def create_car(brand, model, max_speed, category, fuel_type, car_color):
car = Car(brand, model, category, fuel_type)
car.max_speed = max_speed
car.color = car_color
return car


car = create_car("Toyota", "Corrola", "160", "saloon", fuel_type="diesel", car_color="red")
print(car.description())
print("number of cars = ", Vehicles.get_vehicles_by_type("car"))
print(car.color)

IN the create_car function, we are calling the object's instance methods to set attributes max_speed and color.

Overriding class methods

We can also override our parent class methods with instance methods. Let's introduce a description method to the car object

def description(self):
desc_str = f"This vehicle is a {self.brand} {self.model} {self.category} - color {self.color} with a top speed of {self.max_speed} km an hour."
desc_str = desc_str + f"\nIt is has {self.number_of_wheels} wheels. It runs on {self.fuel_type} and its working status is {self.working}"
return desc_str

If you copy that to your code for the car class and create a new object, the parent method of the same name will not be called. The instance object method is called instead. Try it out!

Now call the Vehicles class method of the same name and see what it gives you.

print(Vehicles().description())

Static Methods, as mentioned in the introduction to classes, are generally helper methods that perform some calculations or task that do not affect objects directly, i.e. change their attributes.

For example, we could introduce the following method to our Vehicles class.

@staticmethod
def convert_km_to_mile(km):
return km / 1.6

Add the above to the Vehicles class and call it by adding this to the end of the code

print(f"{car.max_speed} kmh = {Vehicles.convert_km_to_mile(car.max_speed)} mph")

Notice that the difference between calling a static and a class method is the () brackets after the class name.

Encapsulation

As was explained previously, encapsulation is about protecting certain methods and data from being changed outside the class itself. If you look at the Vehicles class you see the following private, encapsulated, methods defined. Each of those methods accesses an encapsulated attribute which is prefixed with __ a double underscore. Look at the attributes defined at the top of the class, and you will see those attributes.

@classmethod@classmethod
def __delete_car(cls):
cls.__num_cars -= 1

@classmethod
def __delete_bike(cls):
cls.__num_bikes -= 1

@classmethod
def __delete_truck(cls):
cls.__num_trucks -= 1

None of the above methods are accessible outside the Vehicles class methods, same for the attributes.

But what if we need the values of those attributes in our code? Well, we define methods for the class Vehicles that return those values for us. In other words, we can get the value we need from outside the class, but cannot affect those value from the outside.

The following methods are used to return the encapsulated values from the Vehicles class

@classmethod
def get_vehicles_by_type(cls, vehicle_type):

match vehicle_type:
case "car":
return cls.__num_cars
case "motortbike":
return cls.__num_bikes
case "truck":
return cls.__num_bikes

@classmethod
def get_vehicle_count(cls):
return cls.__vehicle_count

Taking this a step further, notice the __del__ method in the Cars class. This method is called automatically when we delete a Cars object, e.g. del car In that method we call the Vehicles class method delete_vehicle which takes a parameter and checks it using a match statement. We then call the relevant encapsulated method to reduce the number of cars.

Class Magic Methods

Python has a number of so called 'magic' or 'dundar' methods. These all start and end with double __ underscores, such as the __init__ method. Some of these methods are not really meant to be explicitly called by the developer but rather handle various internals of the class.

For example, the __new__ method is explicitly called by Python prior to the __init__ method. Other methods though are fairly useful additions to our code.

The __dict__ method is useful for listing attributes of an object. You might like to add the following to the code you developed for car objects and check the output.

print(car.__dict__)

To checkout all these magic methods see Python Magic Methods

Operating on classes

Is a class a class? That is the question.

To check if a variable points to an object of a certain Class, use the isinstance function. For example isinstance(car, Car) returns true, if car is an object of the class Car.

Let's take a change of scene and explore the use of some magic methods and something called 'Operator Overloading' using these methods.

Operator overloading is a powerful feature to implement classes that represent concepts that work with arithmetic operators.

Let's implement a ShoppingItem class with item and price attributes.

class ShoppingBasket:

def __init__(self):
self.items = []
self.total = 0

def __add__(self, other):
if not isinstance(other, ShoppingItem):
raise ValueError("Not a shopping item")
self.items.append(other)
self.total += other.price
return self

def __sub__(self, other):
if not isinstance(other, ShoppingItem):
raise ValueError("Not a shopping item")
self.items.remove(other)
self.total -= other.price
return self

def __str__(self):
names = [item.name for item in self.items]
return str(names) + " costs $" + str(self.total)


class ShoppingItem:
def __init__(self, name, price):
self.name = name
self.price = price

def __eq__(self, other):
return isinstance(other, ShoppingItem) and self.name == other.name and self.price == other.price


milk = ShoppingItem("Milk", 5)
egg = ShoppingItem("Egg", 3)
bread = ShoppingItem("Bread", 1)

basket = ShoppingBasket()
basket = basket + milk # calls __add__
basket = basket + egg # calls __add__
basket = basket + bread # calls __add__

print(basket) # calls __str__

basket = basket - bread # calls __sub__

print(basket) # calls __str__

basket2 = ShoppingBasket()
basket2 = basket2 + milk + egg + egg + egg + bread + bread
print(basket2)
print(basket2.__dict__)

What we have done in the above example is overridden some operators using magic methods. The method def __add__ will be activated when we add an item to the basket using the + operator. The same for def __sub__ when we use the minus operator - and so on.

Stick the above code in a file and run it.

Implementing Class Property methods

Properties in classes are a way of encapsulating attributes methods to make setting and getting attributes easier. Python provides a decorator for decorating property methods with the properties function.

Decorators are names preceded by an @ symbol that point to a decorator function that calls some code prior to the decorated function code being called. You'll learn more on decorators later.

In the following example we are defining the temperature method that retrieves the temperature value as a property using the decorator @property. But notice there is another method with the same name too.

The other method for temperature has the decorator @temperature.setter which tells Python that it is the setter function for temperature. The 'setter' decorators define the method to set the value of the attribute. It takes a parameter called temp which is the value passed in for a celsius temperature.

We now have a property temperature that we can call straight, i.e. celsius.temperature or with an assignment, celsius.temperature = 30. To understand why this is useful, look at the setter function, it will answer that question.

If we just had an attribute called temperature and we assigned it a value, it would take that value no questions asked. If we wanted to check the value we would need to call an explicit function, say set_temperature which when called did some work with the input parameter. By using a property we can use that property to get and set the temperature by using the property definition. It makes for more accessible and readable code.

class Temperature:

def __init__(self, temp=0):
self._temperature = temp

@property
def temperature(self):
return self._temperature

@temperature.setter
def temperature(self, temp):
if temp < -273.15:
raise ValueError("Temperature below -273.15 is impossible")
self._temperature = temp

def to_fahrenheit(self):
return (self._temperature * 1.8) + 32

def __str__(self):
return f"{str(self._temperature)} Celsius / {self.to_fahrenheit()} Fahrenheit"


temperature = Temperature(30)
print(temperature.temperature)
print(temperature)
temperature.temperature = -300

The last line of the code above will result in a ValueError. This is expected as the minimum celsius.temperature is -273.15 as per the setter function.

Exercises

  1. Add some motorbikes and trucks to the vehicles mix and write some functions to list all vehicles, their type and attributes.
  1. Create some objects for four different supermarkets, each with the same items but with different prices.
Create a shopping basket class and fill four shopping baskets with all the items from the four markets, each basket with items from a single market.
Establish the cheapest basket items from each market and fill a fifth shopping basket with the cheapest items across all markets.
Finally, list all the items from each basket with a price and percentage difference.
Modules

Modules are separate files of code containing functions and objects or variables that you wish to be accessed via various other modules.

We would normally group related functions and or objects together. Organising modules is fairly straight forward. To import some other code into a module from another module we use the import statement. Imports are used frequently to import built-in Python utilities, functions, objects etc. as well as third party packages that you may want to use. Import statements can import specifics or everything importable from another module.

To be specific about what you want to import you use the from keyword before the module name and list the names of the Functions, Classes that you wish to import..

from a_module import function_a, function_b

to import everything that is importable you use the *

from a_module import *

You can also just import and then reference the imported function like so

import a_module

a_module.function_a()

Once an import has occurred the code imported remains in memory and will not be re-imported by other modules with the same import statement. then takes on a role of providing access to the already loaded code.

Ok, let's see how this works and define some modules from our weather examples. We'll split our temperature and wind conversions into two different modules. Although this is a stretch, it provides a simple modular example.

The following three code blocks should be placed in different files. Create the three files in the same directory and run the weather.py

'temperature.py'

def celsius_to_fahrenheit(celsius):
"""
Converts a Celsius degree to its equivalent Fahrenheit degree

celsius: Celsius degree input
Returns: Equivalent fahrenheit degree
"""
fahrenheit = (celsius * 1.8000) + 32
return fahrenheit


def get_celsius_temp():
"""
Asks for some input for a celsius temperature and passes that input as a parameter,
converting from string to integer
"""
return int(input("Provide a temperature in Celsius"))

'wind.py'

def wind_speed_conversion(wind_kmh=10):
"""
Convert wind speed from kilometers per hour to Miles per hour

wind_kmh: wind speed in kilometers per hour
Returns: Equivalent wind speed in miles per hour
"""

wind_mph = wind_kmh / 1.61
return wind_mph


def get_wind_speed_kmh():
"""
Asks for some input for wind speed in kilometers per hour,
converting from string to integer
"""
return int(input("Provide a kilometer per hour wind speed"))

'weather.py'

from temperature import get_celsius_temp, celsius_to_fahrenheit
from wind import get_wind_speed_kmh, wind_speed_conversion

def weather_conversions(input_functions, conversion_functions):

results = []

for i in range(0, len(input_functions)):
result = input_functions[i]()
conversion = conversion_functions[i](result)
results.append(conversion)

return results


inputs = [get_celsius_temp, get_wind_speed_kmh]
conversions = [celsius_to_fahrenheit, wind_speed_conversion]
results = weather_conversions(inputs, conversions)

print(f"{results[0]} Fahrenheit")
print(f"{results[1]} Wind Speed - mph")

Often, configuration data is imported such as database names, passwords etc. and even security hashes...

'config.py'

DATABASE_NAME = "users"
DATABASE_PASSWORD = "somelongcomplicatedpassword"

'database_handlers.py'

from config import DATABASE_NAME, DATABASE_PASSWORD

def load_database():
# load the database using the provided imported GLOBAL VARIABLES
...

Import Caveats

One caveat for using imports is to avoid circular imports. Circular imports occur when a module imports the module that imports it. 'some_module.py'

from another_module import *

'another_module.py'

from some_module import *

What is happening here is that a module is attempting to import from a module that imports it. This will cause problems!

Imports take up memory and take time. It's not good practise to overload your code with imported modules that are not used in your code.

Useful built-in Python Modules

Python comes with an assortment of useful modules ready to use. We shall look at an example here along with a few use cases, but encourage you to look at and experiment with others as you steadily progress your Python knowledge.

os module

The Python module os is very useful for discovering details and performing some work in the current operating system and user environment.

import os

# Get the name of the operating system
print(os.name)

# Get the current working directory.
print(os.getcwd())

# Get the current process’s user id.
print(os.getpid())

# Get a list of the entries in the directory given by a path.
os.listdir('/')

# Create a directory named path with numeric mode mode.
# os.mkdir(path)

# Recursive directory creation function.
# os.makedirs()

# delete the file path.
# os.remove(path)

# Rename the file or directory src to dst.
# os.rename(src, dst)

# Delete the directory path.
# os.rmdir(path)

# Get the user environment
print(os.environ)

logging module

Logging is a useful way of creating logs of information (like a Captains Log on a ship) that tells a story of events through the journey of our code base. It is frequently used for saving details of events of interest such as errors or other special events.

Remember the following example from our 'Exceptions Tutorial', well this provides a classic situation to use logging to log any errors

import logging

# Specify the file for our error logging and setting the logging level to DEBUG
logging.basicConfig(filename='app-errors.log', encoding='utf-8', level=logging.DEBUG)

teachers_age = 40
students_age = input("Please enter your age: ")
try:
if int(students_age) < teachers_age:
print("You are younger than your teacher")
else:
print("You are older than your teacher, but don't tell anyone.")

except ValueError as e:
print("An error in the input occured - make sure that you input a number only")
# Log the error - but change the text to be more meaningful for degugging.
logging.error("An error occurred with students input in age comparison routine")

Logging writes the information to the console and specific log files, either default files or log files defined by the developer. Logging has a number of convenient methods to help you log different information.

import logging

logging.basicConfig(filename='example.log', encoding='utf-8', level=logging.DEBUG)
logging.debug('This message should go to the log file')
logging.info('So should this')
logging.warning('And this, too')
logging.error('And non-ASCII stuff, too, like Øresund and Malmö')

The above code is from the official Python documentation see Logging HOWTO. The info as with the rest of the documentation is very detailed and useful. We encourage you to study as much of the specifics of Python as you can over time.

datetime module

Another important built-in module is the datatime module. This enables the use of date and time information in our code. It's uses are many, from marking data to be stored in databases, datetime conversions to allowing services to offer correct global date and time data depending on where their users are in the world. This module is quite large with many useful functions. Here we will explore just a few of its many uses.

We'll use a convenience class as a container for our date functions.

The datetime object uses the strftime method for formatting date objects into specific readable date formats.

from datetime import datetime, timedelta, date


class Dates:

@classmethod
def get_datetime_now(cls):
"""
Return now in seconds
"""
return datetime.now()

@classmethod
def get_now_in_sec(cls):
"""
Return now in seconds
"""
return int(datetime.now().strftime("%s"))

@classmethod
def get_date_in_sec(cls, date):
"""
Return date in seconds
"""
return int(date.strftime("%s"))

@classmethod
def get_date_from_sec(cls, sec):
"""
Return now in seconds
"""
return datetime.fromtimestamp(int(sec)).strftime("%d/%m/%Y")

@classmethod
def get_now_in_ms(cls):
"""
Return now in milliseconds
"""
return int(datetime.now().strftime("%s")) * 1000

@classmethod
def get_date_n_days_from_today(cls, days):
"""
Return the date n days from today
"""
return (date.today()+timedelta(days=days)).strftime('%d/%m/%Y')

@classmethod
def get_date_n_days_before_today(cls, days):
"""
Return the date n days before today
"""
return (date.today() - timedelta(days=days)).strftime('%d/%m/%Y')

@classmethod
def datetime_n_hours_from_now(cls, hours):
return datetime.now() + timedelta(hours=hours)

@classmethod
def get_time_from_minutes(cls, mins):
import time
if mins > 0:
seconds = mins * 60
return time.strftime("%H:%M:", time.gmtime(seconds))


print("The date and time now : ", Dates.get_datetime_now())
print("Now in seconds : ", Dates.get_now_in_sec())
print("The date '01/12/2022' in seconds : ", Dates.get_date_in_sec(datetime.strptime('1/12/2022', '%d/%m/%Y')))
print("Now in milliseconds : ", Dates.get_now_in_ms())
print("Date from seconds : ", Dates.get_date_from_sec(Dates.get_now_in_sec()))
print("Date n Days from now |: ", Dates.get_date_n_days_from_today(30))
print("Get date n days from today :", Dates.get_date_n_days_before_today(10))
print("Get the date and time n hours from now : ", Dates.datetime_n_hours_from_now(24))
print("Get the time from minutes : ", Dates.get_time_from_minutes(120))

For a full list of Python modules see Python Modules But be warned, there is a lot to learn.

Introduction to Pip

Pip is a Python package manager used to install third party Python packages/modules

At its basic level Pip is very easy to use from a command-line console/terminal. The following show exactly how easy.

Adding a package

Installing 'pytest' a testing framework package

pip install pytest

Once you have added a package into your Python environment you can use the import statement to import it into your modules.

import pytest

Removing a package

pip uninstall pytest

Running the above will prompt you to remove the package with a yes or no (y/n) response.

Found existing installation: pytest 7.1.1
Uninstalling pytest-7.1.1:
Would remove:
/Users/username/path_to_python/lib/bin/py.test
/Users/username/path_to_python/lib/bin/pytest
/Users/username/path_to_python/lib/lib/python3.10/site-packages/_pytest/*
/Users/username/path_to_python/lib/lib/python3.10/site-packages/pytest-7.1.1.dist-info/*
/Users/username/path_to_python/lib/lib/python3.10/site-packages/pytest/*
Proceed (y/n)?

Hitting the "y" option will proceed with the uninstalling and return confirmation.

 Successfully uninstalled pytest-7.1.1

Installing a specific version of a package

There are nearly always package versions, and sometimes we need to install a specific version to our environment. The standard generally installs the newest version or the most stable version of the package. However, we may need an older version of a package when setting up an existing codebase that has code that relies on a specific function of a package that may have been deprecated and or removed in the latest version. Deprecation is when a package maintainer decides that they will remove some functionality that has been changed or simply withdrawn from the package code. They mark that code in prior releases of a package with a 'Deprecation Warning' to inform developers of it's pending removal.

Thus, it is not infrequent to have to install older versions of packages for code bases that have not yet been updated to account for the removal of any functionality of the package.

As we saw above, the latest version of 'pytest', as of writing, is 7.1.1. If we want to import an earlier version we do so thus:

pip install pytest==6.2.5

Installing our packages using a requirements file

Most of the time we write down our package requirements in a file which is normally called 'requirements.txt'. Doing this provides control and illumination over what packages we will use and what versions.

A requirements.txt file will have the format of package-name==version.

mysql-connector-python==8.0.27
argon2==0.1.10
argon2-cffi==21.3.0
PyJWT==2.3.0
redis==4.1.0

to install our packages using pip and requirements.txt is straight forward.

pip install -r requirements.txt

The -r tells Pip to install from the requirements file.

There is a lot more to Pip and one of the ways to understand what you can do with it is to look over the help information that comes with it. To do this type the following command in the command-line interface console.

pip --help

That's it for this section, you can move on to the next part of this tutorial.